Skip to main content
Version: 1.0.0

Adding Drilling-downs to your chart

In this tutorial we will show how you can add a simple drill down in muze using side effects. Also, we will show how you can drill down using custom drill path.

What is drill-down?

Drilldown is a way to dig deeper into your data. It allows users to navigate from a summary level to a detail level and gain deeper insights about the data. For example, if you are viewing the revenue of all products, you can drill down into each product and see the sales for each month for that product.

Creating a simple drill down effect

To add drill downs to our chart, let's create a side effect class.

class DrillDown extends SurrogateSideEffect {
static formalName() {
return "drill-down";
}

apply(selectionSet) {}
}

Here we have a created a DrillDown side effect and specified a formalName for identifying the side effect.

Updating the canvas on interaction

Now we see how you can update the canvas when the side effect gets triggered on interaction. For that, you need implement the apply method.

apply(selectionSet) {
const model = selectionSet.entrySet.model;
if (!model) {
return this;
}
drillDown(canvas, model);
}

The apply method receives a selectionSet which contains the information of the items we selected.

Now we create a drillDown method which takes the DataModel instance and filters the chart by the data values and applies the data on the chart.

function drillDown(canvas, dm) {
const dimensions = canvas.data().getDimensions();

const xAxisField = canvas.columns()[0];

// Get all the dimensions for drill-down
const drillDownFields = dimensions.filter(
(d) =>
d !== xAxisField && !history.find((data) => data.columns.includes(d)),
);

if (drillDownFields.length) {
const dimVal = dm.getValueAtIndex(xAxisField, 0);
const subtype = dm.getField(xAxisField).schema().subtype;
const formattedVal =
subtype == "temporal"
? DateTimeFormatter.formatAs(dimVal, "%A, %b %d, %Y")
: dimVal;

canvas.columns([drillDownFields[0]]).data(
canvas.data().select({
field: xAxisField,
value: dimVal,
operator: DataModel.ComparisonOperators.EQUAL,
}),
);
}
}

Register the Side Effect

muze.ActionModel.for(canvas).registerSideEffects(DrillDown);

Map side effect with behaviour

First create a singleSelect behaviour and register it in ActionModel and map it to click action.

const { VolatileBehaviour } = muze.Behaviours.standards;

muze.ActionModel.for(canvas)
.registerBehaviouralActions(
class SingleSelectBehaviour extends VolatileBehaviour {
static formalName() {
return "singleSelect";
}
},
)
.registerPhysicalBehaviouralMap({
click: {
behaviours: ["singleSelect"],
},
})
.dissociateBehaviour(["select", "click"]);

We also dissociate the select behaviour from click action as we do not want multi-selection in this chart.

Finally, we map the drilldown side effect with the singleSelect behaviour.

  .config({
interaction: {
singleSelect: {
sideEffects: {
"drill-down": {
enabled: true,
},
},
},
}
})

Example

<div id='chart-container'>
<div id='btn'>
<div id='back-button' class='hide-btn'>&larr; Back</div>
</div>
<div id="chart"></div>
const { muze, getDataFromSearchQuery, env } = viz;
const data = getDataFromSearchQuery();

const SurrogateSideEffect = muze.SideEffects.standards.SurrogateSideEffect;
const html = muze.Operators.html;

// Create a history array to store the canvas data,columns and other configurations
const history = [];

const canvas = muze
.canvas()
.data(rootData)
.rows(["Total"])
.columns(["Product line"])
.title("Sales by product line")
.subtitle("Click on bars to drilldown")
.mount("#chart");

const { VolatileBehaviour } = muze.Behaviours.standards;

muze.ActionModel.for(canvas)
.registerBehaviouralActions(
class SingleSelectBehaviour extends VolatileBehaviour {
static formalName() {
return "singleSelect";
}
},
)
.registerPhysicalBehaviouralMap({
click: {
behaviours: ["singleSelect"],
},
})
.dissociateBehaviour(["select", "click"])
.registerSideEffects(
class DrillDown extends SurrogateSideEffect {
constructor(...params) {
super(...params);
if (this.firebolt.target() === "visual-unit") {
this.disable();
}
}

static formalName() {
return "drill-down";
}

apply(selectionSet) {
const model = selectionSet.entrySet.model;
if (!model) {
return this;
}

drillDown(canvas, model, history);
}
},
);

function drillDown(canvas, dm, history) {
const dimensions = canvas.data().getDimensions();

const xAxisField = canvas.columns()[0];

const drillDownFields = dimensions.filter(
(d) =>
d !== xAxisField && !history.find((data) => data.columns.includes(d)),
);

if (drillDownFields.length) {
const dimVal = dm.getValueAtIndex(xAxisField, 0);

const formattedVal =
dm.getField(xAxisField).schema().subtype === "temporal"
? DataModel.DateTimeFormatter.formatAs(dimVal, "%A, %b %d, %Y")
: dimVal;

history.push({
data: canvas.data(),
columns: [xAxisField],
title: canvas.title()[0](),
subtitle: canvas.subtitle()[0](),
selectedVal: formattedVal,
});

const subtitleVal = history
.map((d) => {
return `${d.columns[0]} = ${d.selectedVal}`;
})
.join(" & ");

canvas.firebolt().dispatchBehaviour("singleSelect", {
criteria: null,
});

canvas
.columns([drillDownFields[0]])
.data(
canvas.data().select({
field: xAxisField,
value: dimVal,
operator: DataModel.ComparisonOperators.EQUAL,
}),
)
.title(`Sales by ${drillDownFields[0]}`)
.subtitle(`where ${subtitleVal}`);
document.getElementById("back-button").className = "";
}
}

document.getElementById("back-button").addEventListener("click", () => {
const { data, columns, title, subtitle } = history.pop();
canvas.data(data).columns(columns).title(title).subtitle(subtitle);

if (!history.length) {
document.getElementById("back-button").className = "hide-btn";
}
});

function kFormatter(num) {
return Math.abs(num) > 999
? Math.sign(num) * (Math.abs(num) / 1000).toFixed(1) + "K"
: Math.sign(num) * Math.abs(num);
}

Create a drilldown with custom drill path

Now we will see an example where user can drill by any dimension. All of the dimensions will be shown in a context menu when we click on the plot. On selecting any dimension, the chart willdrilldown by that dimension.

Create a context menu on plot click

For this, we need to create a context menu with the dimensions that the user can drill by.

Let's get the parentContainer element where we will append the dom element. We will get it by calling the drawingContext method which returns the dom elements of the chart.

const drawingContext = this.drawingContext();
const { parentContainer, htmlContainer } = drawingContext;
const elem = this.createElement(parentContainer, "div", [1], "context-popup");

We will use createElement method to create the dom element in the container.

Get the position of the plot which you have selected,

const layoutBoundBox = parentContainer.getBoundingClientRect();
const unitBoundBox = htmlContainer.getBoundingClientRect();
const offsetLeft = unitBoundBox.left - layoutBoundBox.left;
const offsetTop = unitBoundBox.top - layoutBoundBox.top;
const point = this.plotPointsFromIdentifiers(selectionSet.entrySet.uids)[0];
const posX = point.x + offsetLeft;
const posY = point.y + offsetTop;

Now set the position of the element,

// Set the position of the context menu container
elem.style(
"transform",
`translate(${point.x + offsetLeft + (point.width || 0) + 5}px,${point.y + offsetTop}px)`,
);

Now we will render the data in context menu. Here mount argument in this function is a d3 selection where we will append the data values.

function drawContextPopup(mount, canvas, { dataObj, fieldIndices }, history) {
const dimensions = canvas.data().getDimensions();

const { data, schema } = dataObj;

mount.selectAll(".context-container").remove();
const container = mount.append("div").attr("class", "context-container");

container.append("div").html("&#10005;").attr("class", "close-btn");

container
.selectAll(".fields")
.data(schema)
.enter()
.append("div")
.attr("class", "fields")
.selectAll("span")
.data((d, columnIndex) => {
const { type, subtype } = schema[columnIndex];
let val = data[0][columnIndex];
if (type === "measure") {
val = `$${kFormatter(val)}`;
} else if (subtype === "temporal") {
val = DataModel.DateTimeFormatter.formatAs(val, "%A, %b %d, %Y");
}
return [d.name, val];
})
.enter()
.append("span")
.html((d) => d);

// Get all the dimensions for drill-down
const drillDownFields = dimensions.filter(
(d) =>
d !== canvas.columns()[0] &&
!history.find((data) => data.columns.includes(d)),
);

if (drillDownFields.length) {
container.append("hr").attr("class", ".horizontal-line");

container
.selectAll(".dimension-title")
.data(["Drill down", "Select any dimension"])
.enter()
.append("div")
.attr("class", (d) => `dimension-title ${d.replace(" ", "")}`)
.html((d) => d);

container
.selectAll(".dimensions")
.data(drillDownFields)
.enter()
.append("div")
.attr("class", "dimensions")
.on("click", onDimensionClick)
.html((d) => d);
}
}

DrillDown on selecting dimension from context menu

On clicking on any dimension, we need to set the dimension in canvas columns and attach a DataModel instance filtered by the selected plot value.

const xAxisField = canvas.columns()[0];

const dimVal = data[0][fieldIndices[xAxisField]];

const formattedVal =
schema[fieldIndices[xAxisField]].subtype === "temporal"
? DataModel.DateTimeFormatter.formatAs(dimVal, "%A, %b %d, %Y")
: dimVal;

// Store the drilldown information in a history array
history.push({
data: canvas.data(),
columns: [xAxisField],
title: canvas.title()[0](),
subtitle: canvas.subtitle()[0](),
selectedVal: formattedVal,
});

const subtitleVal = history
.map((d) => {
return `${d.columns[0]} = ${d.selectedVal}`;
})
.join(" & ");

const filteredData = canvas.data().select({
field: xAxisField,
value: dimVal,
operator: DataModel.ComparisonOperators.EQUAL,
});

canvas
.columns([value])
.data(filteredData)
.title(`Sales by ${value}`)
.subtitle(`where ${subtitleVal}`);

Here is the complete code:

<div id='chart-container'>
<div id='btn'>
<div id='back-button' class='hide-btn'>&larr; Back</div>
</div>
<div id="chart"></div>
</div>
#chart {
width: 800px;
height: 550px;
}

.fields {
display: flex;
font-size: 13px;
margin: 5px;
}
.fields span {
display: inline-block;
margin-right: 5px;
min-width: 80px;
}

#back-button {
font-family: Source Sans Pro;
}

#btn {
margin-bottom: 20px;
text-align: right;
}

.horizontal-line {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
padding: 0;
}

.context-popup {
position: absolute;
padding: 5px;
box-shadow: 2px 2px 3px 0px rgba(211,211,211,.5);
opacity: 1;
border: 1px solid rgba(151,151,151,.19);
background: #fbfbfb;
font-size: 12px;
color: #5f5f5f;
border-radius: 1px;
padding-top: 4px;
display: inline-block;
z-index: 9999999999;
will-change: transform;
}

.dimensions {
font-size: 12px;
padding: 5px;
background: #F2F9FB;

color: #847575;
display: inline-block;
cursor: pointer;
border-radius: 3px;
border: 1px solid;
border-color: #c4eac4;
margin: 5px;
}

.dimensions:hover {
background: #626262;
color: #fff;
}

#back-button:hover {
background: #626262;
color: #fff;
}

.dimension-title {
margin: 5px;
font-size: 14px;
}

.Drilldown {
text-align: center;
font-weight: bold;
}

#back-button {
width: 60px;
height: 20px;
padding: 10px;
background: #F2F9FB;
text-align: center;
color: #847575;
display: inline-block;
cursor: pointer;
border-radius: 3px;
border: 1px solid;
border-color: #000;
}

.hide-btn {
visibility: hidden;
}

.close-btn {
text-align: right;
margin-right: 5px;
cursor: pointer;
font-weight: bold;
font-size: 14px;
}
const { muze, getDataFromSearchQuery, env } = viz;
const data = getDataFromSearchQuery();

const SpawnableSideEffect = muze.SideEffects.standards.SpawnableSideEffect;
const html = muze.Operators.html;

// Create a history array to store the canvas data,columns and other configurations
const history = [];

const canvas = muze
.canvas()
.data(rootData)
.rows(["Total"])
.config({
interaction: {
singleSelect: {
sideEffects: {
"context-popup": {
enabled: true,
},
},
},
},
})
.columns(["Product Line"])
.title("Sales by product line")
.subtitle("Click on bars to drilldown")
.mount("#chart");

const { VolatileBehaviour } = muze.Behaviours.standards;

muze.ActionModel.for(canvas)
.registerBehaviouralActions(
class SingleSelectBehaviour extends VolatileBehaviour {
static formalName() {
return "singleSelect";
}
},
)
.registerPhysicalBehaviouralMap({
click: {
behaviours: ["singleSelect"],
},
})
.dissociateBehaviour(["select", "click"])
.registerSideEffects(
class ContextPopup extends SpawnableSideEffect {
constructor(...params) {
super(...params);
if (this.firebolt.target() === "visual-unit") {
this.disable();
}
}

static formalName() {
return "context-popup";
}

apply(selectionSet) {
const drawingContext = this.drawingContext();
const { parentContainer, htmlContainer } = drawingContext;
const elem = this.createElement(
parentContainer,
"div",
[1],
"context-popup",
);

if (!selectionSet.entrySet.model) {
elem.remove();
return this;
}
const layoutBoundBox = parentContainer.getBoundingClientRect();
const unitBoundBox = htmlContainer.getBoundingClientRect();
const offsetLeft = unitBoundBox.left - layoutBoundBox.left;
const offsetTop = unitBoundBox.top - layoutBoundBox.top;
// Get the position of the bars from the selection data
const point = this.plotPointsFromIdentifiers(
selectionSet.entrySet.uids,
)[0];
if (point) {
// Set the position of the context menu container
elem.style(
"transform",
`translate(${point.x + offsetLeft + (point.width || 0) + 5}px,${point.y + offsetTop}px)`,
);
}
const model = selectionSet.entrySet.model;
drawContextPopup(
elem,
canvas,
{
dataObj: model.getData({
excludeFields: ["__id__"],
}),
fieldIndices: model.getDimensions().reduce((acc, dim) => {
acc[dim] = model.getFieldIndex(dim);
return acc;
}, {}),
},
history,
DataModel,
);
}
},
)
.mapSideEffects({
singleSelect: [
"context-popup",
{
name: "highlighter",
options: {
strategy: "focus",
},
},
],
});

document.getElementById("back-button").addEventListener("click", () => {
const { data, columns, title, subtitle } = history.pop();
canvas.data(data).columns(columns).title(title).subtitle(subtitle);

if (!history.length) {
document.getElementById("back-button").className = "hide-btn";
}
});

function drawContextPopup(
mount,
canvas,
{ dataObj, fieldIndices },
history,
DataModel,
) {
const dimensions = canvas.data().getDimensions();

const { data, schema } = dataObj;
mount.selectAll(".context-container").remove();
const container = mount.append("div").attr("class", "context-container");
container
.append("div")
.html("&#10005;")
.attr("class", "close-btn")
.on("click", () => {
// Reset the singleSelect behaviour when close button is clicked.
canvas.firebolt().dispatchBehaviour("singleSelect", {
criteria: null,
});
});

container
.selectAll(".fields")
.data(schema)
.enter()
.append("div")
.attr("class", "fields")
.selectAll("span")
.data((d, columnIndex) => {
const { type, subtype } = schema[columnIndex];
let val = data[0][columnIndex];
if (type === "measure") {
val = `$${kFormatter(val)}`;
} else if (subtype === "temporal") {
val = DataModel.DateTimeFormatter.formatAs(val, "%A, %b %d, %Y");
}
return [d.name, val];
})
.enter()
.append("span")
.html((d) => d);

// Get all the dimensions for drill-down
const drillDownFields = dimensions.filter(
(d) =>
d !== canvas.columns()[0] &&
!history.find((data) => data.columns.includes(d)),
);

if (drillDownFields.length) {
container.append("hr").attr("class", ".horizontal-line");

container
.selectAll(".dimension-title")
.data(["Drill down", "Select any dimension"])
.enter()
.append("div")
.attr("class", (d) => `dimension-title ${d.replace(" ", "")}`)

.html((d) => d);

container
.selectAll(".dimensions")
.data(drillDownFields)
.enter()
.append("div")
.attr("class", "dimensions")
.html((d) => d)
.on("click", function (value) {
const xAxisField = canvas.columns()[0];

const dimVal = data[0][fieldIndices[xAxisField]];

const formattedVal =
schema[fieldIndices[xAxisField]].subtype === "temporal"
? DataModel.DateTimeFormatter.formatAs(dimVal, "%A, %b %d, %Y")
: dimVal;
history.push({
data: canvas.data(),
columns: [xAxisField],
title: canvas.title()[0](),
subtitle: canvas.subtitle()[0](),
selectedVal: formattedVal,
});

canvas.firebolt().dispatchBehaviour("singleSelect", {
criteria: null,
});
const subtitleVal = history
.map((d) => {
return `${d.columns[0]} = ${d.selectedVal}`;
})
.join(" & ");
canvas
.columns([value])
.data(
canvas.data().select({
field: xAxisField,
value: dimVal,
operator: DataModel.ComparisonOperators.EQUAL,
}),
)

.title(`Sales by ${value}`)
.subtitle(`where ${subtitleVal}`);
document.getElementById("back-button").className = "";
mount.remove();
});
}
}

function kFormatter(num) {
return Math.abs(num) > 999
? Math.sign(num) * (Math.abs(num) / 1000).toFixed(1) + "K"
: Math.sign(num) * Math.abs(num);
}